大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 12 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。介紹完 WebGL 運作方式與 2D transform 後,本章節講述的是建構並 transform 渲染成 3D 物件,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
使用 perspective 3D 投影渲染物件時,相當於模擬現實生活眼睛、相機捕捉的光線形成的投影,也是大多 3D 遊戲使用的投影方式,學會這種投影方式,才算是真正進入 3D 渲染的世界,那麼我們就開始吧
先來回顧一下 orthogonal 投影,在這行產生的 transform 矩陣只是調整輸入座標使之落在 clip space 內:
const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, state.projectionZ);
為了讓輸入座標可以用螢幕長寬的 pixel 定位,這個轉換只是做拉伸(0 ~ 螢幕寬高 => 0 ~ 2)及平移(0 ~ 2 => -1 ~ +1),Orthogonal 投影視覺化看起來像是這樣:
沒錯,orthogonal 投影其實是往 -z 的方向投影,也就是說在 clip space 是以
z = -1
的平面進行成像;還有另外一種講法:使用者看者螢幕是看向 clip space 的 +z 方向
現實生活中比較可以找的到以 orthogonal 成像的範例就是影印機的掃描器,有一個大的面接收垂直於該面的光線,而眼睛、相機則是以一個小面積的感光元件,接收特定角度範圍內的光線,這樣的投影方式稱為 perspective projection:
上面這張圖是從側面看的,x 軸方向與螢幕平面垂直,而藍色框起來表示可見、會 transform 到 clip space 的區域,在 orthogonal 是一個立方體;在 perspective 這個區域的形狀叫做 frustum,這個形狀 3D 的樣子像是這樣:
什麼樣的矩陣可以把 frustum 的區域 transform 成 clip space 呢?很不幸的,其實這樣的矩陣不存在,因為這樣的轉換不是線性變換 (linear transformation),根據 3Blue1Brown 的 Youtube 影片 -- 線性變換與矩陣這邊講到的,線性變換後必須保持網格線平行並間隔均等,想像一下把上面 frustum 側邊的邊拉成 clip space 立方體的平行線,這個 transform 就不是線性的
幸好在 vertex shader 輸出的 gl_Position.w
(等同於 gl_Position[3]
) 有一個我們一直沒用到的功能:頂點位置在進入 clip space 之前,會把 gl_Position.x
, gl_Position.y
, gl_Position.z
都除以 gl_Position.w
。有了這個功能,在距離相機越遠的地方輸出越大的 gl_Position.w
,越遠的地方就能接受更寬廣的 xy 平面區域進入 clip space
產生矩陣的 function 接收以下幾個參數:
matrix4.perspective(
fieldOfView,
aspect,
near,
far,
)
fieldOfView
表示看出去的角度有多寬,aspect
控制畫面寬高比(寬/高),near
為靠近相機那面距離相機的距離,far
則為最遠相機能看到的距離
產生 perspective transform 矩陣的 function 我們就叫它 matrix4.perspective()
,網路上當然有許多現成的程式碼/公式可以用,不過筆者認為這一個 transform 很關鍵,就算已經拿到實做,還是想嘗試自己算一下了解這個公式是如何產生的,假設 matrix4.perspective()
要製作的矩陣為 M
:
FOV
為 fieldOfView 的縮寫,接著令 A
表示 a_position
輸入的向量(正確來說,是與 perspective 矩陣 M
相乘的向量),P
表示輸出給 gl_Position
的向量,P'
表示 gl_Position
的 xyz 除以 w 的向量,也就是 clip space 中的位置:
看下面這張圖,經過 M
transform 並且除以 gl_Position.w
之後,圖中之 A1
應該要轉換至 P1'
([1,1,-1]
);而 A2
應該要轉換至 P2'
([1,1,1]
):
定義 gl_Position.w
等於負的 A.z
,使得在距離相機越遠的地方接受更寬廣的區域進入 clip space;FOV
表示看出去『畫面上緣與下緣』之間的角度,也就是說 FOV 的直接作用對象是在 y 軸上;對於 x, y 軸來說,這樣的 transform 理論上不會有旋轉或是平移,從 A
到 P
只有 scalar 做縮放,那麼就來算這兩個 scalar:
接著來算 z 軸的部份,從上方 A1
轉換至 P1'
、A2
轉換至 P2'
的圖來看,z 軸會有平移產生,因此這樣算:
筆者也把公式輸入線上公式視覺化工具來觀察 near, far 與 z 軸輸入輸出的影響:https://www.desmos.com/calculator/dhsp5blfzg
看到這邊讀者應該也有發現,相機視角對著的方向為 -z,面向螢幕外(螢幕到使用者)的方向為 +z,原因筆者也不知道,在猜應該是業界的慣例
基於先前介紹 scale, translate 時各個數值要放在矩陣的哪個位置,得到矩陣(使用電腦上的行列排法):
完整計算流程的 PDF 版本在此,並且把矩陣實做到 lib/matrix.js
內:
perspective: (fieldOfView, aspect, near, far) => {
const f = Math.tan(Math.PI / 2 - fieldOfView / 2);
const rangeInv = 1.0 / (near - far);
return [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (near + far) * rangeInv, -1,
0, 0, far * near * rangeInv * 2, 0,
]
},
來到主程式,把 viewMatrix
從原本 matrix4.projection()
改成 matrix4.perspective()
,fieldOfView
先給上 45 度:
const viewMatrix = matrix4.perspective(45 * Math.PI / 180, gl.canvas.width / gl.canvas.height, 0.1, 2000);
存檔重整後會看到一片慘白,沒有錯誤,但是就是沒東西。以現在來說,因為 matrix4.perspective()
是從原點出發向著 -z
的方向看,而當初規劃 3D 模組的 z 軸是往 +z
的方向長的,更別說頂點時是用螢幕 pixel 為單位製作的,現在看不到東西其實很正常;要看到東西,就得『移動視角』讓物件在 frustum 內,這就留到下篇再來繼續實做
最後筆者也把 fieldOfView
的使用者控制實做進去取代原本的 projectionZ
,完整程式碼可以在這邊找到:
後記:如果把 fieldOfView
拉到很大約 160 度以上,其實可以看到右上角出現東西:
原來1/tan(fov/2) = tan(Pi/2 - fov/2)阿
不過不直接用
Mx = 1/tan(fov/2)aspect 是有什麼特別的原因嗎?
Mx = 1/tan(fov/2)aspect 在幾何上不是更直觀嗎?
數學上這兩個方法是相同的,我測試改到程式碼中結果也確實完全一樣,所以要這樣寫也沒有什麼問題
文章中這樣寫的原因純粹只是在當初學習的路上看到的文章、程式碼使用這樣的算式,最後也就沿用下來了